/*
 * Copyright 2012 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.android.apps.mytracks.content;

import com.google.android.apps.mytracks.stats.TripStatistics;
import com.google.android.apps.mytracks.util.LocationUtils;
import com.google.android.apps.mytracks.util.UnitConversions;

import android.database.Cursor;
import android.location.Location;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Locale;
import java.util.SortedSet;
import java.util.TreeSet;

/**
 * Engine for searching for tracks and waypoints by text.
 *
 * @author Rodrigo Damazio
 */
public class SearchEngine {

  /** WHERE query to get tracks by name. */
  private static final String TRACK_SELECTION_QUERY =
      TracksColumns.NAME + " LIKE ? OR " +
      TracksColumns.DESCRIPTION + " LIKE ? OR " +
      TracksColumns.CATEGORY + " LIKE ?";

  /** WHERE query to get waypoints by name. */
  private static final String WAYPOINT_SELECTION_QUERY =
      WaypointsColumns.NAME + " LIKE ? OR " +
      WaypointsColumns.DESCRIPTION + " LIKE ? OR " +
      WaypointsColumns.CATEGORY + " LIKE ?";

  /** Order of track results. */
  private static final String TRACK_SELECTION_ORDER = TracksColumns._ID + " DESC LIMIT 1000";

  /** Order of waypoint results. */
  private static final String WAYPOINT_SELECTION_ORDER = WaypointsColumns._ID + " DESC";

  /** How much we promote a match in the track category. */
  private static final double TRACK_CATEGORY_PROMOTION = 2.0;

  /** How much we promote a match in the track description. */
  private static final double TRACK_DESCRIPTION_PROMOTION = 8.0;

  /** How much we promote a match in the track name. */
  private static final double TRACK_NAME_PROMOTION = 16.0;

  /** How much we promote a waypoint result if it's in the currently-selected track. */
  private static final double CURRENT_TRACK_WAYPOINT_PROMOTION = 2.0;

  /** How much we promote a track result if it's the currently-selected track. */
  private static final double CURRENT_TRACK_DEMOTION = 0.5;

  /** Maximum number of waypoints which will be retrieved and scored. */
  private static final int MAX_SCORED_WAYPOINTS = 100;

  /** Oldest timestamp for which we rank based on time (2000-01-01 00:00:00.000) */
  private static final long OLDEST_ALLOWED_TIMESTAMP = 946692000000L;

  /**
   * Description of a search query, along with all contextual data needed to execute it.
   */
  public static class SearchQuery {
    public SearchQuery(String textQuery, Location currentLocation, long currentTrackId,
        long currentTimestamp) {
      this.textQuery = textQuery.toLowerCase(Locale.getDefault());
      this.currentLocation = currentLocation;
      this.currentTrackId = currentTrackId;
      this.currentTimestamp = currentTimestamp;
    }

    public final String textQuery;
    public final Location currentLocation;
    public final long currentTrackId;
    public final long currentTimestamp;
  }

  /**
   * Description of a search result which has been retrieved and scored.
   */
  public static class ScoredResult {
    ScoredResult(Track track, double score) {
      this.track = track;
      this.waypoint = null;
      this.score = score;
    }

    ScoredResult(Waypoint waypoint, double score) {
      this.track = null;
      this.waypoint = waypoint;
      this.score = score;
    }

    public final Track track;
    public final Waypoint waypoint;
    public final double score;

    @Override
    public String toString() {
      return "ScoredResult ["
          + (track != null ? ("trackId=" + track.getId() + ", ") : "")
          + (waypoint != null ? ("wptId=" + waypoint.getId() + ", ") : "")
          + "score=" + score + "]";
    }
  }

  /** Comparador for scored results. */
  private static final Comparator<ScoredResult> SCORED_RESULT_COMPARATOR =
      new Comparator<ScoredResult>() {
        @Override
        public int compare(ScoredResult r1, ScoredResult r2) {
          // Score ordering.
          int scoreDiff = Double.compare(r2.score, r1.score);
          if (scoreDiff != 0) {
            return scoreDiff;
          }

          // Make tracks come before waypoints.
          if (r1.waypoint != null && r2.track != null) {
            return 1;
          } else if (r1.track != null && r2.waypoint != null) {
            return -1;
          }

          // Finally, use arbitrary ordering, by ID.
          long id1 = r1.track != null ? r1.track.getId() : r1.waypoint.getId();
          long id2 = r2.track != null ? r2.track.getId() : r2.waypoint.getId();
          long idDiff = id2 - id1;
          return Long.signum(idDiff);
        }
      };

  private final MyTracksProviderUtils providerUtils;

  public SearchEngine(MyTracksProviderUtils providerUtils) {
    this.providerUtils = providerUtils;
  }

  /**
   * Executes a search query and returns a set of sorted results.
   *
   * @param query the query to execute
   * @return a set of results, sorted according to their score
   */
  public SortedSet<ScoredResult> search(SearchQuery query) {
    ArrayList<Track> tracks = new ArrayList<Track>();
    ArrayList<Waypoint> waypoints = new ArrayList<Waypoint>();
    TreeSet<ScoredResult> scoredResults = new TreeSet<ScoredResult>(SCORED_RESULT_COMPARATOR);

    retrieveTracks(query, tracks);
    retrieveWaypoints(query, waypoints);

    scoreTrackResults(tracks, query, scoredResults);
    scoreWaypointResults(waypoints, query, scoredResults);

    return scoredResults;
  }

  /**
   * Retrieves tracks matching the given query from the database.
   *
   * @param query the query to retrieve for
   * @param tracks list to fill with the resulting tracks
   */
  private void retrieveTracks(SearchQuery query, ArrayList<Track> tracks) {
    String queryLikeSelection = "%" + query.textQuery + "%";
    String[] trackSelectionArgs = new String[] {
        queryLikeSelection,
        queryLikeSelection,
        queryLikeSelection };

    Cursor cursor = null;
    try {
      cursor = providerUtils.getTrackCursor(
          TRACK_SELECTION_QUERY, trackSelectionArgs, TRACK_SELECTION_ORDER);
      if (cursor != null) {
        tracks.ensureCapacity(cursor.getCount());
        while (cursor.moveToNext()) {
          tracks.add(providerUtils.createTrack(cursor));
        }
      }
    } finally {
      if (cursor != null) {
        cursor.close();
      }
    }
  }

  /**
   * Retrieves waypoints matching the given query from the database.
   *
   * @param query the query to retrieve for
   * @param waypoints list to fill with the resulting waypoints
   */
  private void retrieveWaypoints(SearchQuery query, ArrayList<Waypoint> waypoints) {
    String queryLikeSelection2 = "%" + query.textQuery + "%";
    String[] waypointSelectionArgs = new String[] {
        queryLikeSelection2,
        queryLikeSelection2,
        queryLikeSelection2 };
    Cursor cursor = null;
    try {
      cursor = providerUtils.getWaypointCursor(WAYPOINT_SELECTION_QUERY, waypointSelectionArgs,
          WAYPOINT_SELECTION_ORDER, MAX_SCORED_WAYPOINTS);
      if (cursor != null) {
        waypoints.ensureCapacity(cursor.getCount());
        while (cursor.moveToNext()) {
          Waypoint waypoint = providerUtils.createWaypoint(cursor);
          if (LocationUtils.isValidLocation(waypoint.getLocation())) {
            waypoints.add(waypoint);
          }
        }
      }
    } finally {
      if (cursor != null) {
        cursor.close();
      }
    }
  }

  /**
   * Scores a collection of track results.
   *
   * @param tracks the results to score
   * @param query the query to score for
   * @param output the collection to fill with scored results
   */
  private void scoreTrackResults(Collection<Track> tracks, SearchQuery query, Collection<ScoredResult> output) {
    for (Track track : tracks) {
      // Calculate the score.
      double score = scoreTrackResult(query, track);

      // Add to the output.
      output.add(new ScoredResult(track, score));
    }
  }

  /**
   * Scores a single track result.
   *
   * @param query the query to score for
   * @param track the results to score
   * @return the score for the track
   */
  private double scoreTrackResult(SearchQuery query, Track track) {
    double score = 1.0;

    score *= getTitleBoost(query, track.getName(), track.getDescription(), track.getCategory());

    TripStatistics statistics = track.getTripStatistics();
    // TODO: Also boost for proximity to the currently-centered position on the map.
    score *= getDistanceBoost(query, statistics.getMeanLatitude(), statistics.getMeanLongitude());

    long meanTimestamp = (statistics.getStartTime() + statistics.getStopTime()) / 2L;
    score *= getTimeBoost(query, meanTimestamp);

    // Score the currently-selected track lower (user is already there, wouldn't be searching for it).
    if (track.getId() == query.currentTrackId) {
      score *= CURRENT_TRACK_DEMOTION;
    }

    return score;
  }

  /**
   * Scores a collection of waypoint results.
   *
   * @param waypoints the results to score
   * @param query the query to score for
   * @param output the collection to fill with scored results
   */
  private void scoreWaypointResults(Collection<Waypoint> waypoints, SearchQuery query, Collection<ScoredResult> output) {
    for (Waypoint waypoint : waypoints) {
      // Calculate the score.
      double score = scoreWaypointResult(query, waypoint);

      // Add to the output.
      output.add(new ScoredResult(waypoint, score));
    }
  }

  /**
   * Scores a single waypoint result.
   *
   * @param query the query to score for
   * @param waypoint the results to score
   * @return the score for the waypoint
   */
  private double scoreWaypointResult(SearchQuery query, Waypoint waypoint) {
    double score = 1.0;

    Location location = waypoint.getLocation();
    score *= getTitleBoost(query, waypoint.getName(), waypoint.getDescription(), waypoint.getCategory());
    // TODO: Also boost for proximity to the currently-centered position on the map.
    score *= getDistanceBoost(query, location.getLatitude(), location.getLongitude());
    score *= getTimeBoost(query, location.getTime());

    // Score waypoints in the currently-selected track higher (searching inside the current track).
    if (query.currentTrackId != -1 && waypoint.getTrackId() == query.currentTrackId) {
      score *= CURRENT_TRACK_WAYPOINT_PROMOTION;
    }

    return score;
  }

  /**
   * Calculates the boosting of the score due to the field(s) in which the match occured.
   *
   * @param query the query to boost for
   * @param name the name of the track or waypoint
   * @param description the description of the track or waypoint
   * @param category the category of the track or waypoint
   * @return the total boost to be applied to the result
   */
  private double getTitleBoost(SearchQuery query,
      String name, String description, String category) {
    // Title boost: track name > description > category.
    double boost = 1.0;
    if (name.toLowerCase(Locale.getDefault()).contains(query.textQuery)) {
      boost *= TRACK_NAME_PROMOTION;
    }
    if (description.toLowerCase(Locale.getDefault()).contains(query.textQuery)) {
      boost *= TRACK_DESCRIPTION_PROMOTION;
    }
    if (category.toLowerCase(Locale.getDefault()).contains(query.textQuery)) {
      boost *= TRACK_CATEGORY_PROMOTION;
    }
    return boost;
  }

  /**
   * Calculates the boosting of the score due to the recency of the matched entity.
   *
   * @param query the query to boost for
   * @param timestamp the timestamp to calculate the boost for
   * @return the total boost to be applied to the result
   */
  private double getTimeBoost(SearchQuery query, long timestamp) {
    if (timestamp < OLDEST_ALLOWED_TIMESTAMP) {
      // Safety: if timestamp is too old or invalid, don't rank based on time.
      return 1.0;
    }

    // Score recent tracks higher.
    long timeAgoHours = (long) ((query.currentTimestamp - timestamp) * UnitConversions.MS_TO_S
        * UnitConversions.S_TO_MIN * UnitConversions.MIN_TO_HR);
    if (timeAgoHours > 0L) {
      return squash(timeAgoHours);
    } else {
      // Should rarely happen (track recorded in the last hour).
      return Double.POSITIVE_INFINITY;
    }
  }

  /**
   * Calculates the boosting of the score due to proximity to a location.
   *
   * @param query the query to boost for
   * @param latitude the latitude to calculate the boost for
   * @param longitude the longitude to calculate the boost for
   * @return the total boost to be applied to the result
   */
  private double getDistanceBoost(SearchQuery query, double latitude, double longitude) {
    if (query.currentLocation == null) {
      return 1.0;
    }

    float[] distanceResults = new float[1];

    Location.distanceBetween(
        latitude, longitude,
        query.currentLocation.getLatitude(), query.currentLocation.getLongitude(),
        distanceResults);

    // Score tracks close to the current location higher.
    double distanceKm = distanceResults[0] * UnitConversions.M_TO_KM;
    if (distanceKm > 0.0) {
      // Use the inverse of the amortized distance.
      return squash(distanceKm);
    } else {
      // Should rarely happen (distance is exactly 0).
      return Double.POSITIVE_INFINITY;
    }
  }

  /**
   * Squashes a number by calculating 1 / log (1 + x).
   */
  private static double squash(double x) {
    return 1.0 / Math.log1p(x);
  }
}
